--[[

Usage: lualint [-r|-s] filename.lua [ [-r|-s] [filename.lua] ...]

lualint performs static analysis of Lua source code's usage of global
variables..  It uses luac's bytecode listing.  It reports all accesses
to undeclared global variables, which catches many typing errors in
variable names.  For example:

  local really_aborting
  local function abort() os.exit(1) end
  if not os.getenv("HOME") then
    realy_aborting = true
    abortt()
  end

reports:

  /tmp/example.lua:4: *** global SET of realy_aborting
  /tmp/example.lua:5: global get of abortt

It is primarily designed for use on LTN7-style modules, where each
source file only exports one global symbol.  (A module contained in
the file "foobar.lua" should only export the symbol "foobar".)

A "relaxed" mode is available for source not in LTN7 style.  It only
detects reads from globals that were never set.  The switch "-r" puts
lualint into relaxed mode for the following files; "-s" switches back
to strict.

Required packages are tracked, although not recursively.  If you call
"myext.process()" you should require "myext", and not depend on other
dependencies to load it.  LUA_PATH is followed as usual to find
requirements.

Some (not strictly LTN7) modules may wish to export other variables
into the global environment.  To do so, use the declare function:

  declare "xpairs"
  function xpairs(node)
    [...]

Similarly, to quiet warnings about reading global variables you are
aware may be unavailable:

  lint_ignore "lua_fltk_version"
  if lua_fltk_version then print("fltk loaded") end

One way of defining these is in a module "declare.lua":

  function declare(s)
  end
  declare "lint_ignore"
  function lint_ignore(s)
  end

(Setting declare is OK, because it's in the "declare" module.)  These
functions don't have to do anything, or in fact actually exist!  They
can be in dead code:

  if false then declare "xpairs" end

This is because lualint only performs a rather primitive and cursory
scan of the bytecode.  Perhaps declarations should only be allowed in
the main chunk.

TODO:

The errors don't come out in any particular order.

Should switch to Rici's parser, which should do a much better job of
this, and allow detection of some other common situations.

CREDITS:

Jay Carlson (nop@nop.com)

This is all Ben Jackson's (ben@ben.com) fault, who did some similar
tricks in MOO.

]]


local function Set(l)
  local t = {}
  for _,v in ipairs(l) do
    t[v] = true
  end
  return t
end

local ignoreget = Set{
"LUA_PATH", "_G", "_LOADED", "_TRACEBACK", "_VERSION", "__pow", "arg",
"assert", "collectgarbage", "coroutine", "debug", "dofile", "error",
"gcinfo", "getfenv", "getmetatable", "io", "ipairs", "loadfile",
"loadlib", "loadstring", "math", "newproxy", "next", "os", "pairs",
"pcall", "print", "rawequal", "rawget", "rawset", "require",
"setfenv", "setmetatable", "string", "table", "tonumber", "tostring",
"type", "unpack", "xpcall",
}

local function fileexists(fname)
  local f = io.open(fname)
  if f then
    f:close()
    return true
  else
    return false
  end
end

-- borrowed from LTN11
local function locate(name)
  local path = LUA_PATH
  if type(path) ~= "string" then
    path = os.getenv "LUA_PATH" or "./?.lua"
  end
  for path in string.gfind(path, "[^;]+") do
    path = string.gsub(path, "?", name)
    if fileexists(path) then
      return path
    end
  end
  return nil
end


local function scanfile(filename)
  local modules = {}
  local declared = {}
  local lint_ignored = {}
  local refs = {}
  local saw_last = nil

  local context, curfunc

  if not fileexists(filename) then
    return nil, "file "..filename.." does not exist"
  end

  -- Run once to see if it parses correctly

  if not os.execute("luac -o lualint.tmp "..filename) then
    return nil, "file "..filename.." did not successfully parse"
  end

  if not fileexists("lualint.tmp") then
    return nil, "file "..filename.." did not successfully parse"
  end

  assert(os.remove("lualint.tmp"))

  local bc = assert(io.popen("luac -l -p "..filename))

  for line in bc:lines() do
    -- main <examples/xhtml2wiki.lua:0> (64 instructions, 256 bytes at 0x805c1a0)
    -- function <examples/xhtml2wiki.lua:13> (6 instructions, 24 bytes at 0x805c438)
    local found, _, type, fname = string.find(line, "(%w+) <([^>]+)>")
    if found then
      if context == "main" then fname="*MAIN*" end
      curfunc = fname
    end

    -- print("sawlast", saw_last)
    -- 	2	[1]	LOADK    	1 1	; "lazytree"
    local found, _, constname = string.find(line, '%sLOADK .-;%s"(.-)"')
    if saw_last and found then
      if saw_last == "require" then
        -- print("require", constname)
        table.insert(modules, constname)
      elseif saw_last == "declare" then
        -- print("declare", constname)
        table.insert(declared, constname)
      elseif saw_last == "lint_ignore" then
        lint_ignored[constname] = true
      end
    end

    -- 	4	[2]	GETGLOBAL	0 0	; require
    local found, _, lineno, instr, gname = string.find(line, "%[(%d+)%]%s+([SG]ETGLOBAL).-; (.+)")
    if found then
      local t = refs[curfunc] or {SETGLOBAL={n=0}, GETGLOBAL={n=0}}
      local err = {name=gname, lineno=lineno}
      table.insert(t[instr], err)
      refs[curfunc] = t
      saw_last = gname
    else
      saw_last = nil
    end
  end
  bc:close()
  return modules, declared, lint_ignored, refs
end

local found_sets = false
local found_gets = false
local parse_failed = false
local import_failed = true

-- print("args", arg[1])
local function lint(filename, relaxed)
  local modules, declared, lint_ignored, refs = scanfile(filename)

  if not modules then
    print(string.format("%s:%d: *** could not parse: %s ", filename, 1, declared))
    parse_failed = true
    return
  end

  local imported_declare_set = {}
  for i,module in ipairs(modules) do
    local path = locate(module)
    if not path then
      print(string.format("%s:%d: could not find imported module %s ", filename, 1, module))
      import_failed = true
    else
      local success, imported_declare, _, _ = scanfile(path)
      if not success then
        print(string.format("%s:%d: could not parse import: %s ", path, 1, imported_declare))
        import_failed = true
      else
        for i,declared in ipairs(imported_declare) do
          imported_declare_set[declared] = true
        end
      end
    end
  end

  local moduleset = Set(modules)
  local declaredset = Set(declared)

  local self_name = nil
  do
    local _
    if string.find(filename, "/") then
      _, _, self_name = string.find(filename, ".-/(%w+)%.lua")
    else
      _,_,self_name = string.find(filename, "(%w+)%.lua")
    end
  end
  -- print("selfname", self_name)

  local was_set = {}

  local function will_warn_for(name)
    if relaxed and was_set[name] then
      return false
    end
    if name == self_name or
      lint_ignored[name] or
      ignoreget[name] or
      moduleset[name] or
      declaredset[name] or
      imported_declare_set[name] then
      return false
    end
    return true
  end

  for f,t in pairs(refs) do
    for _,r in ipairs(t.SETGLOBAL) do
      if r.name ~= self_name and not declaredset[r.name] then
        was_set[r.name] = true
        if not relaxed then
          print(string.format("%s:%d: *** global SET of %s", filename, r.lineno, r.name))
          found_sets = true
        end
      end
    end
  end

  for f,t in pairs(refs) do
    for _,r in ipairs(t.GETGLOBAL) do
      if will_warn_for(r.name) then
        print(string.format("%s:%d: global get of %s", filename, r.lineno, r.name))
        found_gets = true
      end
    end
  end
end

if arg.n == 0 then
  print("usage: lualint filename.lua [filename.lua ...]")
  os.exit(1)
end

local relaxed_mode = false

for i,v in ipairs(arg) do
  if v == "-r" then
    relaxed_mode = true
  elseif v == "-s" then
    relaxed_mode = false
  else
    lint(v, relaxed_mode)
  end
end

if parse_failed then
  os.exit(3)
elseif import_failed then
  os.exit(1)
elseif found_sets then
  os.exit(2)
elseif found_gets then
  os.exit(1)
else
  os.exit(0)
end
